8-4 菜单及嵌套菜单数据更新
本节实现菜单的更新接口,这是菜单模块中最复杂的操作。嵌套树形数据的更新涉及多种场景判断——新增子菜单、删除子菜单、更新子菜单——采用先删后建策略简化逻辑。
一、更新操作的复杂性分析
更新嵌套菜单数据时,传入的 children 可能有以下几种情况:
| 场景 | 数据特征 | 处理方式 |
|---|---|---|
| 已有子菜单带 ID | { id: 11, name: 'xxx' } | 需要更新 |
| 新增子菜单无 ID | { name: 'new', path: '/new' } | 需要新增 |
| 原有子菜单未传入 | 数据库有但请求中没有 | 需要删除 |
| meta 数据变更 | meta 字段内容不同 | 需要更新 |
如果逐一判断每个子菜单是新增、更新还是删除,逻辑会非常复杂。
二、先删后建策略
采用与 Policy 更新相同的策略:先删除所有子菜单,再根据传入数据重新创建。
权衡取舍:
| 方面 | 先删后建 | 逐一判断 |
|---|---|---|
| 代码复杂度 | 低 | 高 |
| ID 稳定性 | ID 会变化(自增) | ID 保持不变 |
| 性能 | 略低(删除+创建) | 略高(只更新变化部分) |
| 数据一致性 | 高(事务保障) | 需要更多边界处理 |
对于管理后台的菜单场景,菜单数据量小且更新频率低,ID 变化的影响可以接受。
三、DTO 设计
复用 CreateMenuDto 作为 Update 的基础,增加可选的 id 字段:
// dto/create-menu.dto.ts 中添加
export class CreateMenuDto {
@IsOptional()
@IsInt()
id?: number; // 更新时传入
@IsString()
name: string;
@IsString()
path: string;
// ... 其他字段
@IsOptional()
@ValidateNested()
@Type(() => CreateMenuMetaDto)
meta?: CreateMenuMetaDto;
@IsOptional()
@ValidateNested({ each: true })
@Type(() => CreateMenuDto)
children?: CreateMenuDto[];
}
typescript
// dto/update-menu.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateMenuDto } from './create-menu.dto';
export class UpdateMenuDto extends PartialType(CreateMenuDto) {}
typescript
四、Service 实现核心逻辑
1. 收集菜单 ID 的辅助方法(复用于删除和更新)
// menu.service.ts
private async collectMenuIds(menuId: number, prisma: PrismaClient): Promise<number[]> {
const idsToDelete: number[] = [];
const collect = async (id: number) => {
const item = await prisma.menu.findUnique({
where: { id },
include: { children: true },
});
if (item?.children?.length) {
const childMenuIds = await Promise.all(
item.children.map(async (child) => {
idsToDelete.push(child.id);
await collect(child.id);
}),
);
}
};
await collect(menuId);
return idsToDelete;
}
typescript
2. 更新方法实现
async update(id: number, dto: UpdateMenuDto) {
const { children, meta, ...restData } = dto;
return this.prisma.$transaction(async (prisma) => {
// 1. 更新基础字段和 meta
const updated = await prisma.menu.update({
where: { id },
data: {
...restData,
...(meta && {
meta: { update: meta },
}),
},
});
// 2. 如果没有 children,直接返回查询结果
if (!children || children.length === 0) {
return prisma.menu.findUnique({
where: { id },
include: {
meta: true,
children: { include: { meta: true } },
},
});
}
// 3. 收集所有子菜单 ID
const menuIds = await this.collectMenuIds(id, prisma);
const childIds = menuIds.filter((menuId) => menuId !== id);
// 4. 删除子菜单的 meta 数据
await prisma.meta.deleteMany({
where: { menuId: { in: childIds } },
});
// 5. 删除子菜单
await prisma.menu.deleteMany({
where: { id: { in: childIds } },
});
// 6. 递归创建新的子菜单
await Promise.all(
children.map((child) => this.createNested(child, id, prisma)),
);
// 7. 返回更新后的完整数据
return prisma.menu.findUnique({
where: { id },
include: {
meta: true,
children: {
include: {
meta: true,
children: true,
},
},
},
});
});
}
typescript
3. createNested 方法改造(支持事务内的 PrismaClient)
private async createNested(
dto: CreateMenuDto,
parentId: number,
prisma: PrismaClient, // 使用事务内的 prisma 实例
) {
const { meta, children, ...restData } = dto;
const parent = await prisma.menu.create({
data: {
...restData,
parentId,
...(meta && { meta: { create: meta } }),
},
});
if (children && children.length > 0) {
await Promise.all(
children.map((child) => this.createNested(child, parent.id, prisma)),
);
}
return parent;
}
typescript
五、事务内 PrismaClient 的重要性
事务内部的所有数据库操作必须使用事务提供的 prisma 实例,而非 this.prismaClient:
// 正确:使用事务内的 prisma
this.prisma.$transaction(async (prisma) => {
await prisma.menu.findUnique({ ... }); // 使用 prisma 参数
await this.createNested(child, id, prisma); // 传递给辅助方法
});
// 错误:使用 this.prismaClient(脱离事务上下文)
this.prisma.$transaction(async (prisma) => {
await this.prismaClient.menu.findUnique({ ... }); // 不在事务中!
});
typescript
六、优化:无 children 时跳过删除重建
// 关键判断逻辑
if (!children || children.length === 0) {
// 仅更新基础字段,不触碰子菜单
return prisma.menu.findUnique({
where: { id },
include: { meta: true, children: { include: { meta: true } } },
});
}
typescript
好处:
- 前端限制只能更新一级菜单时,逻辑更简洁
- 不会影响子菜单的 ID 数据
- 避免不必要的数据库操作
七、更新流程图解
PATCH /menu/14
Body: { name: "注册登录", meta: { title: "新标题" }, children: [...] }
│
├── 1. prisma.menu.update → 更新基础字段 + meta
│
├── 2. 有 children?→ Yes
│ ├── collectMenuIds(14) → [15, 16, 17]
│ ├── 过滤掉自身 ID → childIds = [15, 16, 17]
│ ├── meta.deleteMany WHERE menuId IN [15, 16, 17]
│ ├── menu.deleteMany WHERE id IN [15, 16, 17]
│ └── Promise.all → 递归创建新的 children
│
└── 3. findUnique + include → 返回完整嵌套数据
PATCH /menu/14
Body: { name: "注册登录" }
│
└── 仅更新基础字段 → 直接返回
text
八、测试验证
# 仅更新基础字段(不影响 children)
PATCH /menu/14
{ "name": "注册登录-更新", "meta": { "title": "单页布局" } }
# 更新并替换 children
PATCH /menu/14
{
"name": "内容管理",
"children": [
{ "name": "article", "path": "article", "meta": { "title": "文章" } },
{ "name": "video", "path": "video", "meta": { "title": "视频" } }
]
}
bash
九、总结
| 知识点 | 说明 |
|---|---|
| 先删后建 | 简化嵌套数据更新的判断逻辑 |
| 事务保障 | 删除 meta + 删除 menu + 创建 children 在同一事务中 |
| 事务内 PrismaClient | 辅助方法必须接收事务内的 prisma 实例 |
| 条件判断优化 | 无 children 时跳过删除重建,保持 ID 稳定 |
| createNested 复用 | 创建和更新共用同一个递归方法 |
↑